blob: 59393a259fc6ccb64c20c4f0a28c1cd94ba6ce55 [file]
import { isConnectionReady, type ChatConfigurationReason } from '@maka/core/connection-readiness';
import type { LlmConnection, ProviderType } from '@maka/core/llm-connections';
import { connectionEnabledModelIds, PROVIDER_DEFAULTS } from '@maka/core/llm-connections';
import {
isOAuthSubscriptionProvider,
resolveOAuthSubscriptionTokens,
resolveSelectedModelContextWindow,
type OAuthSubscriptionTokens,
} from '@maka/runtime';
import type { ConnectionStore, CredentialKind, CredentialStore } from '@maka/storage';
export interface ReadySessionTarget {
connection: LlmConnection;
apiKey: string;
model: string;
oauthTokens?: OAuthSubscriptionTokens;
}
/** One selectable model in the `/model` picker, tagged with its owning connection. */
export interface ModelChoice {
connectionSlug: string;
connectionName: string;
providerType: ProviderType;
model: string;
isDefaultConnection: boolean;
/** Maximum context tokens for this model, resolved from the connection or provider catalog. */
contextWindow?: number;
}
export function selectableModelIdsForTarget(
target: Pick<ReadySessionTarget, 'connection' | 'model'>,
): string[] {
// The picker mirrors the desktop's curated visibility: only the connection's
// enabled models are offered (legacy connections collapse to their default
// model, never the full discovered catalog). The session's current model
// stays selectable even when the user curated it out.
const candidates = [target.model, ...connectionEnabledModelIds(target.connection)];
const ids: string[] = [];
const seen = new Set<string>();
for (const candidate of candidates) {
const id = candidate.trim();
if (!id || seen.has(id)) continue;
seen.add(id);
ids.push(id);
}
return ids;
}
export interface ResolveDefaultSessionTargetInput {
connectionStore: Pick<ConnectionStore, 'get' | 'getDefault'>;
credentialStore: Pick<CredentialStore, 'getSecret'> & Partial<Pick<CredentialStore, 'setSecret'>>;
requestedModel?: string;
now?: () => number;
fetchFn?: typeof fetch;
}
export async function resolveDefaultSessionTarget(
input: ResolveDefaultSessionTargetInput,
): Promise<ReadySessionTarget> {
return resolveSessionTargetForSlug(await input.connectionStore.getDefault(), input);
}
/**
* Resolve a ready target for a specific connection slug — the per-session path
* the backend uses so the active session's connection (not the global default)
* decides which provider a turn runs on, mirroring the desktop app.
*/
export async function resolveSessionTargetForSlug(
slug: string | null | undefined,
input: ResolveDefaultSessionTargetInput,
): Promise<ReadySessionTarget> {
if (!slug || slug === 'fake') throw noRealConnection('missing_default_connection');
const connection = await input.connectionStore.get(slug);
if (!connection) throw noRealConnection('connection_missing');
return resolveReadyTargetForConnection(connection, input);
}
async function resolveReadyTargetForConnection(
connection: LlmConnection,
input: ResolveDefaultSessionTargetInput,
): Promise<ReadySessionTarget> {
const oauthProviderType = isOAuthSubscriptionProvider(connection.providerType)
? connection.providerType
: null;
const oauthTokens = oauthProviderType
? await resolveOAuthSubscriptionTokens({
providerType: oauthProviderType,
slug: connection.slug,
credentialStore: input.credentialStore,
now: input.now,
fetchFn: input.fetchFn,
})
: undefined;
const credentialKind = credentialKindForConnection(connection);
const secret =
!oauthProviderType && credentialKind
? await input.credentialStore.getSecret(connection.slug, credentialKind)
: '';
const apiKey = oauthProviderType ? oauthTokens?.access_token : secret;
const verdict = isConnectionReady({
connection,
hasSecret: typeof apiKey === 'string' && apiKey.length > 0,
requestedModel: input.requestedModel,
});
if (!verdict.ready) throw noRealConnection(verdict.reason);
return {
connection: oauthTokens?.base_url
? { ...connection, baseUrl: oauthTokens.base_url }
: connection,
apiKey: apiKey ?? '',
model: verdict.model,
...(oauthTokens ? { oauthTokens } : {}),
};
}
/**
* Every selectable model across all ready connections, for the `/model` picker.
* Readiness here is cheap and side-effect free — a stored secret, no OAuth token
* refresh or network — since the backend does the real resolution at turn time;
* this only decides which connections' models are worth offering.
*/
export async function listReadyModelChoices(input: {
connectionStore: Pick<ConnectionStore, 'list' | 'getDefault'>;
credentialStore: Pick<CredentialStore, 'getSecret'>;
}): Promise<ModelChoice[]> {
const [connections, defaultSlug] = await Promise.all([
input.connectionStore.list(),
input.connectionStore.getDefault(),
]);
const choices: ModelChoice[] = [];
for (const connection of connections) {
if (connection.slug === 'fake') continue;
// Isolate each connection: reading one connection's secret can throw (a
// legacy or corrupt credentials.json), and this list is an optional
// convenience for the /model picker — it must never take down startup. A
// failing or not-ready connection is simply skipped, so a usable default
// (e.g. a keyless local model) still launches.
try {
const credentialKind = credentialKindForConnection(connection);
const secret = credentialKind
? await input.credentialStore.getSecret(connection.slug, credentialKind)
: '';
const hasSecret =
credentialKind === null || (typeof secret === 'string' && secret.length > 0);
const verdict = isConnectionReady({ connection, hasSecret });
if (!verdict.ready) continue;
for (const model of selectableModelIdsForTarget({ connection, model: verdict.model })) {
choices.push({
connectionSlug: connection.slug,
connectionName: connection.name,
providerType: connection.providerType,
model,
isDefaultConnection: connection.slug === defaultSlug,
contextWindow: resolveSelectedModelContextWindow(connection, model),
});
}
} catch {
// Unreadable credentials for this connection: skip it, keep the rest.
}
}
return choices;
}
function credentialKindForConnection(connection: LlmConnection): CredentialKind | null {
const authKind = PROVIDER_DEFAULTS[connection.providerType]?.authKind;
switch (authKind) {
case 'api_key':
return 'api_key';
case 'oauth_token':
return 'oauth_token';
case 'none':
return null;
default:
return 'api_key';
}
}
function noRealConnection(reason: ChatConfigurationReason): Error {
return new Error(`NO_REAL_CONNECTION:${reason}`);
}