blob: c73c1c5f4c406e0a5f6b45324bb5cfb9f95c9084 [file]
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { defaultEnabledModelIdsWhenOmitted } from '@maka/core';
import {
PROVIDER_DEFAULTS,
connectionEnabledModelIds,
migrateConnectionV1ToV2,
reconcileConnectionAfterEnabledModelsChange,
persistedBaseUrl,
reconcileConnectionAfterModelFetch,
validateSlug,
type CreateConnectionInput,
type LlmConnection,
type UpdateConnectionInput,
} from '@maka/core';
import { pruneRelayModelProfiles } from '@maka/core';
import { normalizeOptionalRequestBodyOverlay } from '@maka/core';
import { relayProfilesForStorage } from './relay-profile-store.js';
export interface ConnectionStore {
getSnapshot(): Promise<ConnectionStoreSnapshot>;
list(): Promise<LlmConnection[]>;
get(slug: string): Promise<LlmConnection | null>;
create(input: CreateConnectionInput): Promise<LlmConnection>;
update(slug: string, patch: UpdateConnectionInput): Promise<LlmConnection>;
updateIfUnchanged(
slug: string,
expectedUpdatedAt: number,
patch: UpdateConnectionInput,
): Promise<LlmConnection | null>;
delete(slug: string): Promise<void>;
save(connection: LlmConnection): Promise<LlmConnection>;
remove(slug: string): Promise<void>;
getDefault(): Promise<string | null>;
setDefault(slug: string | null): Promise<void>;
/**
* Resolve the default connection in a SINGLE read snapshot (slug + connection
* come from the same file read, so a concurrent write cannot tear them apart)
* and tolerate an unrelated invalid entry (a stale record that fails
* migration is skipped, not allowed to strand a valid default). Returns null
* when there is no default, or when the default entry itself is unusable.
*/
getDefaultConnection(): Promise<LlmConnection | null>;
}
export interface ConnectionStoreSnapshot {
defaultSlug: string | null;
connections: LlmConnection[];
}
interface ConnectionsFile {
defaultSlug: string | null;
connections: LlmConnection[];
}
const emptyConnectionsFile = (): ConnectionsFile => ({ defaultSlug: null, connections: [] });
export function createConnectionStore(workspaceRoot: string): ConnectionStore {
return new FileConnectionStore(workspaceRoot);
}
/**
* Construct a store rooted at an explicit `llm-connections.json` file path
* rather than a workspace directory. Use this when the caller already holds
* the full file path (e.g. headless honoring the MAKA_CONNECTIONS_PATH file-path
* contract); prefer {@link createConnectionStore} when you have the workspace
* root, since desktop/CLI share that root across stores.
*/
export function createConnectionStoreForFile(connectionsFilePath: string): ConnectionStore {
return new FileConnectionStore(dirname(connectionsFilePath), connectionsFilePath);
}
class FileConnectionStore implements ConnectionStore {
private readonly path: string;
private queue: Promise<void> = Promise.resolve();
constructor(workspaceRoot: string, explicitPath?: string) {
this.path = explicitPath ?? join(workspaceRoot, 'llm-connections.json');
}
async getSnapshot(): Promise<ConnectionStoreSnapshot> {
return await this.read();
}
async list(): Promise<LlmConnection[]> {
return (await this.read()).connections;
}
async get(slug: string): Promise<LlmConnection | null> {
return (await this.read()).connections.find((connection) => connection.slug === slug) ?? null;
}
async create(input: CreateConnectionInput): Promise<LlmConnection> {
const err = validateSlug(input.slug);
if (err) throw new Error(err);
let created: LlmConnection | null = null;
await this.withQueue(async () => {
const file = await this.readUnlocked();
if (file.connections.some((connection) => connection.slug === input.slug)) {
throw new Error(`Connection slug already exists: ${input.slug}`);
}
const defaults = PROVIDER_DEFAULTS[input.providerType];
if (!defaults) {
throw new Error(`Unknown provider type "${input.providerType}"`);
}
const now = Date.now();
const baseUrl = persistedBaseUrl(input.providerType, input.baseUrl);
const defaultModel = input.defaultModel || defaults.fallbackModels[0] || '';
// Profiles prune against the same selection the connection will store —
// not just the default. Import/create can seed enabledModelIds + profiles
// together; using only defaultModel would drop every non-default profile.
const enabledModelIds = connectionEnabledModelIds({
defaultModel,
// Only fill a missing selection. Explicit [] / subset stays as stated.
enabledModelIds:
input.enabledModelIds ?? defaultEnabledModelIdsWhenOmitted(input.providerType),
});
const relayModelProfiles = relayProfilesForStorage(
input.providerType,
input.relayModelProfiles,
enabledModelIds,
);
const requestBodyOverlay =
input.requestBodyOverlay === undefined
? undefined
: normalizeOptionalRequestBodyOverlay(input.requestBodyOverlay);
const next: LlmConnection = {
slug: input.slug,
name: input.name || defaults.label,
providerType: input.providerType,
...(baseUrl ? { baseUrl } : {}),
defaultModel,
enabled: true,
enabledModelIds,
createdAt: now,
updatedAt: now,
...(input.extras ? { extras: input.extras } : {}),
...(relayModelProfiles === undefined ? {} : { relayModelProfiles }),
...(requestBodyOverlay === undefined ? {} : { requestBodyOverlay }),
};
file.connections.push(next);
claimVacantWorkspaceDefault(file, next);
created = next;
await this.write(file);
});
if (!created) throw new Error(`Failed to create connection: ${input.slug}`);
return created;
}
async update(slug: string, patch: UpdateConnectionInput): Promise<LlmConnection> {
const updated = await this.updateInternal(slug, patch);
if (!updated) throw new Error(`No such connection: ${slug}`);
return updated;
}
async updateIfUnchanged(
slug: string,
expectedUpdatedAt: number,
patch: UpdateConnectionInput,
): Promise<LlmConnection | null> {
return await this.updateInternal(slug, patch, expectedUpdatedAt);
}
private async updateInternal(
slug: string,
patch: UpdateConnectionInput,
expectedUpdatedAt?: number,
): Promise<LlmConnection | null> {
let updated: LlmConnection | null = null;
await this.withQueue(async () => {
const file = await this.readUnlocked();
const index = file.connections.findIndex((connection) => connection.slug === slug);
if (index < 0) return;
const current = file.connections[index]!;
if (expectedUpdatedAt !== undefined && current.updatedAt !== expectedUpdatedAt) return;
const updatesTestStatus =
Object.prototype.hasOwnProperty.call(patch, 'lastTestStatus') ||
Object.prototype.hasOwnProperty.call(patch, 'lastTestAt') ||
Object.prototype.hasOwnProperty.call(patch, 'lastTestMessage');
const updatesModelCache =
Object.prototype.hasOwnProperty.call(patch, 'models') ||
Object.prototype.hasOwnProperty.call(patch, 'modelSource') ||
Object.prototype.hasOwnProperty.call(patch, 'modelsFetchedAt');
const clearsTestStatus =
!updatesTestStatus &&
(patch.apiKey !== undefined ||
patch.baseUrl !== undefined ||
patch.defaultModel !== undefined ||
patch.models !== undefined ||
patch.requestBodyOverlay !== undefined);
const models = updatesModelCache ? patch.models : current.models;
// A patch carrying `enabledModelIds` is the user stating a selection, so
// it is written as stated — including empty. Anything else re-asserts a
// choice they just withdrew. `reconcileConnectionAfterEnabledModelsChange`
// owns the one rule that follows from it: a default outside the new set
// is no longer the default.
const statesEnabledModels = Object.prototype.hasOwnProperty.call(patch, 'enabledModelIds');
let defaultModel = patch.defaultModel ?? current.defaultModel;
let enabledModelIds: string[];
if (statesEnabledModels) {
const selection = reconcileConnectionAfterEnabledModelsChange(
{ defaultModel },
patch.enabledModelIds ?? [],
);
defaultModel = selection.defaultModel;
enabledModelIds = selection.enabledModelIds;
} else {
enabledModelIds = connectionEnabledModelIds({
defaultModel,
enabledModelIds: current.enabledModelIds,
});
}
// Authoritative live inventory wins: a retired default (common after
// Moonshot renamed moonshot-v1-* → kimi-k2.*) must not strand the
// connection as model_not_enabled once models are fetched. Fallback
// catalogs and metadata-only updates do not own this selection.
const writesFetchedModels =
Object.prototype.hasOwnProperty.call(patch, 'models') && patch.modelSource === 'fetched';
if (writesFetchedModels && models && models.length > 0) {
const reconciled = reconcileConnectionAfterModelFetch(
{
defaultModel,
enabledModelIds,
hasModelInventory: (current.models?.length ?? 0) > 0,
},
models,
);
defaultModel = reconciled.defaultModel;
enabledModelIds = reconciled.enabledModelIds;
}
// Endpoint-keyed semantics, matching the catalog document store: the
// comparison must be between the CANONICALIZED endpoints — a patch
// restating the current endpoint (or its equivalent) is not a move.
const nextBaseUrl =
patch.baseUrl !== undefined
? persistedBaseUrl(current.providerType, patch.baseUrl)
: current.baseUrl;
const endpointChanged = nextBaseUrl !== current.baseUrl;
const requestBodyOverlay =
patch.requestBodyOverlay === undefined
? current.requestBodyOverlay
: patch.requestBodyOverlay === null
? undefined
: normalizeOptionalRequestBodyOverlay(patch.requestBodyOverlay);
const {
requestBodyOverlay: _currentRequestBodyOverlay,
...currentWithoutRequestBodyOverlay
} = current;
const next: LlmConnection = {
...currentWithoutRequestBodyOverlay,
name: patch.name ?? current.name,
baseUrl: nextBaseUrl,
defaultModel,
enabled: patch.enabled ?? current.enabled,
enabledModelIds,
models,
modelSource: updatesModelCache ? patch.modelSource : current.modelSource,
modelsFetchedAt: updatesModelCache ? patch.modelsFetchedAt : current.modelsFetchedAt,
lastTestStatus: updatesTestStatus
? patch.lastTestStatus
: clearsTestStatus
? undefined
: current.lastTestStatus,
lastTestAt: updatesTestStatus
? patch.lastTestAt
: clearsTestStatus
? undefined
: current.lastTestAt,
lastTestMessage: updatesTestStatus
? patch.lastTestMessage
: clearsTestStatus
? undefined
: current.lastTestMessage,
extras: patch.extras ?? current.extras,
...(requestBodyOverlay === undefined ? {} : { requestBodyOverlay }),
// Profiles only ever describe enabled models of THIS endpoint on a
// relay connection: an explicit table passes the same gate+prune
// every write shape applies (a new table in a moving update declares
// itself bound to the NEW endpoint, matching the catalog store);
// an untouched one retires with the old endpoint — declarations
// belong to the relay that answered for them, and a model id that
// coincides across endpoints is a different model — and only then
// is pruned against the selection just computed.
relayModelProfiles:
patch.relayModelProfiles === undefined
? endpointChanged
? undefined
: pruneRelayModelProfiles(current.relayModelProfiles, enabledModelIds)
: relayProfilesForStorage(
current.providerType,
patch.relayModelProfiles,
enabledModelIds,
),
updatedAt: Date.now(),
};
file.connections[index] = next;
// The workspace default is the pair {connection, model}. A connection
// that is disabled, or that no longer has a default model, cannot supply
// half of it — leaving the slug behind showed a "默认" badge next to a
// picker that read 未设置.
if (file.defaultSlug === slug && (next.enabled === false || !next.defaultModel)) {
file.defaultSlug = null;
}
// The other direction: a provider with no `fallbackModels` is created
// with no model at all, so its first discovery is where it becomes able
// to hold the default. Without this the user finished setting up their
// only connection and onboarding still had nothing to point at.
claimVacantWorkspaceDefault(file, next);
updated = next;
await this.write(file);
});
return updated;
}
async delete(slug: string): Promise<void> {
await this.remove(slug);
}
async save(connection: LlmConnection): Promise<LlmConnection> {
// save() is a full-snapshot boundary, so callers providing authoritative
// fetched models must already reconcile defaultModel/enabledModelIds
// through `reconcileConnectionAfterModelFetch` — which is what the OAuth
// account syncs now do. update() performs that reconciliation itself for
// partial fetched-model patches.
//
// The selection rule deliberately does NOT live here. This boundary sees
// only a snapshot, so it cannot tell a selection the user just stated from
// one a sync echoed back unchanged; enforcing "written as stated" on it
// threw away the repaired default a sync had just computed for a model the
// provider had retired, leaving the connection pointing at nothing.
let saved: LlmConnection | null = null;
await this.withQueue(async () => {
const file = await this.readUnlocked();
const index = file.connections.findIndex((item) => item.slug === connection.slug);
const now = Date.now();
// save() is a full-replace write; route it through persistedBaseUrl too,
// or a caller handing back defaults.baseUrl (e.g. OAuth sync) pins the
// connection to the current default. Profiles come from the same
// gate+prune as create/update — snapshot semantics must not bypass
// either profile invariant.
const baseUrl = persistedBaseUrl(connection.providerType, connection.baseUrl);
const {
baseUrl: _omit,
relayModelProfiles: _rawProfiles,
requestBodyOverlay: _rawRequestBodyOverlay,
...rest
} = connection;
const enabledModelIds = connectionEnabledModelIds(connection);
const relayModelProfiles = relayProfilesForStorage(
connection.providerType,
connection.relayModelProfiles,
enabledModelIds,
);
const requestBodyOverlay =
connection.requestBodyOverlay === undefined
? undefined
: normalizeOptionalRequestBodyOverlay(connection.requestBodyOverlay);
const next: LlmConnection = {
...rest,
...(baseUrl ? { baseUrl } : {}),
enabled: connection.enabled ?? true,
enabledModelIds,
...(relayModelProfiles === undefined ? {} : { relayModelProfiles }),
...(requestBodyOverlay === undefined ? {} : { requestBodyOverlay }),
createdAt: connection.createdAt ?? now,
updatedAt: connection.updatedAt ?? now,
};
if (index >= 0) file.connections[index] = next;
else file.connections.push(next);
// Same {connection, model} pair as in update(): a connection with no
// default model supplies only half of it, so it cannot keep the slug.
// Without this an OAuth resync re-pointed the workspace default at a
// connection whose model list the user had just emptied, and the list
// showed a 默认 badge over 未设置.
if (file.defaultSlug === connection.slug && (next.enabled === false || !next.defaultModel)) {
file.defaultSlug = null;
}
if (index < 0) claimVacantWorkspaceDefault(file, next);
await this.write(file);
saved = next;
});
if (!saved) throw new Error(`Failed to save connection: ${connection.slug}`);
return saved;
}
async remove(slug: string): Promise<void> {
await this.withQueue(async () => {
const file = await this.readUnlocked();
file.connections = file.connections.filter((connection) => connection.slug !== slug);
if (file.defaultSlug === slug) file.defaultSlug = null;
await this.write(file);
});
}
async getDefault(): Promise<string | null> {
return (await this.read()).defaultSlug;
}
async setDefault(slug: string | null): Promise<void> {
await this.withQueue(async () => {
const file = await this.readUnlocked();
if (slug) {
const connection = file.connections.find((item) => item.slug === slug);
if (!connection) throw new Error(`No such connection: ${slug}`);
if (!connection.enabled) throw new Error(`Connection is disabled: ${slug}`);
// Half of the {connection, model} pair is not a default. Symmetric with
// the disabled check: a connection that cannot supply a model to start
// a chat on cannot be the one a chat starts on.
if (!connection.defaultModel) throw new Error(`Connection has no default model: ${slug}`);
}
file.defaultSlug = slug;
await this.write(file);
});
}
async getDefaultConnection(): Promise<LlmConnection | null> {
const snapshot = await this.readDefaultSnapshot();
if (!snapshot.defaultSlug) return null;
return snapshot.connections.find((c) => c.slug === snapshot.defaultSlug) ?? null;
}
private async read(): Promise<ConnectionsFile> {
return this.readUnlocked();
}
private async readUnlocked(): Promise<ConnectionsFile> {
try {
const raw = JSON.parse(await readFile(this.path, 'utf8')) as unknown;
const parsed = normalizeConnectionsFile(raw);
const connections = parsed.connections.map((connection) =>
migrateConnectionV1ToV2(connection),
);
return {
defaultSlug: normalizeDefaultSlug(parsed.defaultSlug, connections),
connections,
};
} catch (error) {
if ((error as { code?: string }).code === 'ENOENT') return emptyConnectionsFile();
throw error;
}
}
/**
* Read-only snapshot for default resolution. Mirrors {@link readUnlocked}
* but with a deliberately different fault contract: an entry that fails
* {@link migrateConnectionV1ToV2} is DROPPED, not thrown. {@link readUnlocked}
* (used by every write path) stays fail-closed so a corrupt file never
* silently feeds a write; this path only answers "is there a usable
* default?", where an unrelated stale entry must not strand a valid default
* and silently switch the consumer to hardcoded fallback.
*
* The default entry itself still has to migrate cleanly: if the configured
* default is the broken one, there genuinely is no usable default.
*/
private async readDefaultSnapshot(): Promise<ConnectionsFile> {
try {
const raw = JSON.parse(await readFile(this.path, 'utf8')) as unknown;
const parsed = normalizeConnectionsFile(raw);
const connections: LlmConnection[] = [];
for (const entry of parsed.connections) {
try {
connections.push(migrateConnectionV1ToV2(entry));
} catch {
// Skip an un-migratable entry rather than failing the whole read.
}
}
return {
defaultSlug: normalizeDefaultSlug(parsed.defaultSlug, connections),
connections,
};
} catch (error) {
if ((error as { code?: string }).code === 'ENOENT') return emptyConnectionsFile();
throw error;
}
}
private async write(file: ConnectionsFile): Promise<void> {
await mkdir(dirname(this.path), { recursive: true });
const tempPath = `${this.path}.${process.pid}.${Date.now()}.tmp`;
await writeFile(tempPath, JSON.stringify(file, null, 2) + '\n', 'utf8');
await rename(tempPath, this.path);
}
private withQueue(operation: () => Promise<void>): Promise<void> {
const next = this.queue.then(operation, operation);
this.queue = next.catch(() => {});
return next;
}
}
function normalizeConnectionsFile(value: unknown): ConnectionsFile {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error('Invalid connection file: expected an object');
}
const record = value as Partial<ConnectionsFile>;
if (!Array.isArray(record.connections)) {
throw new Error('Invalid connection file: connections must be an array');
}
if (
record.defaultSlug !== undefined &&
record.defaultSlug !== null &&
typeof record.defaultSlug !== 'string'
) {
throw new Error('Invalid connection file: defaultSlug must be a string or null');
}
return {
defaultSlug: record.defaultSlug ?? null,
connections: record.connections,
};
}
/**
* A workspace with no default takes one from the connection the user is
* working on, so a fresh install is usable as soon as its first connection is
* — including the four providers that ship no `fallbackModels`, which only
* become able to hold it at their first discovery rather than at create.
*
* Only from the connection the user is working on. `save()` is the snapshot
* boundary the OAuth sync writes on, and `connections:list` runs that sync
* before every read, so letting it claim any vacant slug meant that clearing
* your own default handed the workspace to whichever account happened to sync
* first — your next chat went to a different provider, without you touching
* anything. A sync that is bringing a brand-new connection into existence is
* the one exception: that is the same event as create().
*/
function claimVacantWorkspaceDefault(file: ConnectionsFile, connection: LlmConnection): void {
if (file.defaultSlug) return;
if (connection.enabled === false || !connection.defaultModel) return;
file.defaultSlug = connection.slug;
}
function normalizeDefaultSlug(
defaultSlug: string | null | undefined,
connections: LlmConnection[],
): string | null {
if (!defaultSlug) return null;
const connection = connections.find((item) => item.slug === defaultSlug);
// Same pair rule as the write paths, applied to whatever is already on disk:
// a file written before they enforced it can still point at a connection with
// no default model, and that reads back as a 默认 badge over 未设置.
if (!connection || connection.enabled === false || !connection.defaultModel) return null;
return connection.slug;
}