Skip to content
Merged
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 @@ -244,6 +244,12 @@ export interface LocalRuntimeApplication {
variant?: string;
sessionId?: string;
}): Promise<boolean>;
/** Adds or removes a model favorite; absent when the host has no preference store. */
setFavorite?(input: {
providerId: string;
modelId: string;
favorite: boolean;
}): Promise<boolean>;
};
readonly modelProviders?: {
listProviderPresets(): Promise<readonly ByokProviderPresetView[]>;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import type { GlobalEvent } from '@mavis/shared/global-events';
import { isLegacyManagedMinimaxProvider } from '@mavis/config';

import type {
CodexOAuthManager,
LocalModelProviderService,
ModelSystemOwner,
ModelProviderView,
UserModelInputView,
import {
annotateModelFavorites,
modelFavoriteRefs,
type CodexOAuthManager,
type LocalModelProviderService,
type ModelFavoritesPreference,
type ModelSystemOwner,
type ModelProviderView,
type UserModelInputView,
} from '../../service/model-system/index.js';
import { watchGlobalEvents, watchProcessEvents } from '../events.js';
import type { ModelProviderApplication } from './model-provider-application.js';
Expand Down Expand Up @@ -35,6 +38,7 @@ export interface ProcessLocalApplicationOptions {
readonly providers: LocalModelProviderService;
readonly listProviderPresets: ModelSystemOwner['listProviderPresets'];
readonly oauth: Pick<CodexOAuthManager, 'getStatus' | 'startLogin' | 'cancelLogin'>;
readonly favorites?: Pick<ModelFavoritesPreference, 'list' | 'set'>;
};
readonly peripherals: Required<
Pick<
Expand Down Expand Up @@ -124,10 +128,7 @@ export function createProcessLocalApplication(
},
...(options.instructions ? { instructions: options.instructions } : {}),
configuration: options.peripherals.configuration,
models: {
list: (request = {}) => options.modelProvider.application.list(request),
select: (request) => options.modelProvider.application.select(request),
},
models: createModelsApplication(options.modelProvider),
modelProviders: {
listProviderPresets: () => options.modelProvider.listProviderPresets(),
getCodexOAuthStatus: async () => options.modelProvider.oauth.getStatus(),
Expand Down Expand Up @@ -218,3 +219,40 @@ function toUserModelInputs(
: {}),
}));
}

function createModelsApplication(
modelProvider: ProcessLocalApplicationOptions['modelProvider'],
): NonNullable<LocalRuntimeApplication['models']> {
const favorites = modelProvider.favorites;
const list = (request: { sessionId?: string } = {}) =>
modelProvider.application.list(request);
if (!favorites) {
return { list, select: (request) => modelProvider.application.select(request) };
}
return {
list: async (request = {}) => {
const models = await list(request);
// A broken preference row must never hide the catalog.
let saved: ReturnType<typeof favorites.list> = [];
try {
saved = favorites.list();
} catch {
saved = [];
}
return annotateModelFavorites(models, saved);
},
select: (request) => modelProvider.application.select(request),
setFavorite: async (request) => {
// Prune stale favorites only against a catalog we actually read.
let catalog: ReturnType<typeof modelFavoriteRefs> | undefined;
try {
catalog = modelFavoriteRefs(await list({}));
} catch {
catalog = undefined;
}
if (catalog && catalog.length === 0) catalog = undefined;
favorites.set(request, catalog);
return true;
},
};
}
13 changes: 13 additions & 0 deletions packages/local-runtime-v2/src/local/cli-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -657,6 +657,19 @@ export class CliService {
return this.requireCapability("models", "Model").select(input);
}

/** Resolves false when the host has no model-favorite store. */
async setModelFavorite(
input: Parameters<
NonNullable<
NonNullable<LocalRuntimeApplication["models"]>["setFavorite"]
>
>[0],
): Promise<boolean> {
const models = this.requireCapability("models", "Model");
if (!models.setFavorite) return false;
return models.setFavorite(input);
}

listUserModelProviders() {
return this.requireCapability(
"modelProviders",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";

import { createProcessLocalApplication } from "../../../application/session/process-local-application.js";
import { DatabaseClient } from "../../../infra/db/client.js";
import { initializeDatabase } from "../../../infra/db/initialize.js";
import { upsertPreferenceValue } from "../../../infra/db/preference-values.js";
import { annotateModelFavorites, ModelFavoritesPreference } from "./model-favorites.js";

const cleanup: Array<() => Promise<void> | void> = [];
afterEach(async () => {
for (const close of cleanup.splice(0).reverse()) await close();
});

async function openDatabase() {
const dataDir = await mkdtemp(join(tmpdir(), "model-favorites-"));
cleanup.push(() => rm(dataDir, { recursive: true, force: true }));
const database = new DatabaseClient({ dataDir });
cleanup.push(() => database.close());
await initializeDatabase({ database, dataDir });
return database;
}

const m3 = { providerId: "minimax", modelId: "MiniMax-M3" };
const kimi = { providerId: "kimi", modelId: "kimi-k3" };
const deepseek = { providerId: "deepseek", modelId: "deepseek-v4-pro" };

describe("ModelFavoritesPreference", () => {
it("appends new favorites last and survives a reopen", async () => {
const database = await openDatabase();
const favorites = new ModelFavoritesPreference(database.db);
expect(favorites.list()).toEqual([]);
favorites.set({ ...kimi, favorite: true });
favorites.set({ ...m3, favorite: true });
favorites.set({ ...kimi, favorite: true }); // idempotent, keeps position
expect(favorites.list()).toEqual([kimi, m3]);
favorites.set({ ...kimi, favorite: false });
expect(new ModelFavoritesPreference(database.db).list()).toEqual([m3]);
});

it("degrades malformed stored values instead of throwing", async () => {
const database = await openDatabase();
const favorites = new ModelFavoritesPreference(database.db);
upsertPreferenceValue(database.db, "model-favorites", { not: "a list" });
expect(favorites.list()).toEqual([]);
upsertPreferenceValue(database.db, "model-favorites", [
"minimax/MiniMax-M3",
{ providerId: "kimi" },
{ providerId: " kimi ", modelId: " kimi-k3 " },
{ ...kimi },
null,
]);
expect(favorites.list()).toEqual([kimi]);
// An empty id is ignored rather than stored.
expect(favorites.set({ providerId: "", modelId: "x", favorite: true })).toEqual([kimi]);
});

it("keeps stale favorites until a write prunes them against the catalog", async () => {
const database = await openDatabase();
const favorites = new ModelFavoritesPreference(database.db);
favorites.set({ ...deepseek, favorite: true });
favorites.set({ ...kimi, favorite: true });
// deepseek left the catalog: reading keeps it, the next write drops it.
expect(favorites.list()).toEqual([deepseek, kimi]);
favorites.set({ ...m3, favorite: true }, [m3, kimi]);
expect(favorites.list()).toEqual([kimi, m3]);
});

it("annotates catalog rows with their favorite position without reordering", () => {
const rows = annotateModelFavorites(
[{ ...m3, selected: true }, { ...kimi }, { ...deepseek }],
[deepseek, m3],
);
expect(rows).toEqual([
{ ...m3, selected: true, favorite: true, favoriteOrder: 1 },
{ ...kimi },
{ ...deepseek, favorite: true, favoriteOrder: 0 },
]);
});
});

describe("process-local model favorites", () => {
function application(
list: () => Promise<readonly unknown[]>,
favorites?: ModelFavoritesPreference,
) {
return createProcessLocalApplication({
eventBus: { subscribe: vi.fn(() => () => undefined) },
skills: {} as never,
plugins: {} as never,
workspace: {} as never,
plan: {} as never,
peripherals: {} as never,
modelProvider: {
application: { list, select: vi.fn(async () => true) },
providers: {} as never,
listProviderPresets: vi.fn(async () => []),
oauth: {} as never,
...(favorites ? { favorites } : {}),
},
});
}

it("marks favorites in the listed catalog and prunes only against a readable catalog", async () => {
const database = await openDatabase();
const favorites = new ModelFavoritesPreference(database.db);
favorites.set({ ...deepseek, favorite: true });
let catalog: () => Promise<readonly unknown[]> = async () => [m3, kimi];
const app = application(() => catalog(), favorites);

// The catalog read fails: the stale favorite must survive the write.
catalog = async () => {
throw new Error("catalog offline");
};
await expect(app.models?.setFavorite?.({ ...kimi, favorite: true })).resolves.toBe(true);
expect(favorites.list()).toEqual([deepseek, kimi]);

catalog = async () => [m3, kimi];
await app.models?.setFavorite?.({ ...m3, favorite: true });
expect(favorites.list()).toEqual([kimi, m3]);
await expect(app.models?.list()).resolves.toEqual([
{ ...m3, favorite: true, favoriteOrder: 1 },
{ ...kimi, favorite: true, favoriteOrder: 0 },
]);
});

it("lists the plain catalog when the preference store fails or is absent", async () => {
const broken = {
list: () => {
throw new Error("database is locked");
},
set: vi.fn(),
} as unknown as ModelFavoritesPreference;
await expect(application(async () => [m3], broken).models?.list()).resolves.toEqual([m3]);
const withoutStore = application(async () => [m3]);
await expect(withoutStore.models?.list()).resolves.toEqual([m3]);
expect(withoutStore.models?.setFavorite).toBeUndefined();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import type { AppDb } from '../../../infra/db/client.js';
import { readPreferenceValue, upsertPreferenceValue } from '../../../infra/db/preference-values.js';

const MODEL_FAVORITES_PREFERENCE_KEY = 'model-favorites';

export interface ModelFavoriteRef {
readonly providerId: string;
readonly modelId: string;
}

export interface SetModelFavoriteInput extends ModelFavoriteRef {
readonly favorite: boolean;
}

/**
* User-starred models, stored in insertion order like pins: a new favorite is
* appended to the end. Entries only identify a model; effort, thinking and
* context stay with the picker's normal selection flow.
*
* Malformed stored values degrade to an empty or partial list instead of
* failing, and favorites whose model is no longer in the catalog are kept until
* the next write, where they are pruned against the catalog passed in.
*/
export class ModelFavoritesPreference {
constructor(private readonly db: AppDb) {}

list(): ModelFavoriteRef[] {
return sanitize(readPreferenceValue(this.db, MODEL_FAVORITES_PREFERENCE_KEY));
}

/**
* @param catalog Models currently listed; when provided, stale favorites
* outside it are dropped. Omit it to leave existing entries untouched.
*/
set(input: SetModelFavoriteInput, catalog?: readonly ModelFavoriteRef[]): ModelFavoriteRef[] {
const target = toRef(input);
if (!target) return this.list();
const known = catalog ? new Set(catalog.map(refKey)) : undefined;
const current = this.list();
const exists = current.some((entry) => refKey(entry) === refKey(target));
const next = current.filter(
(entry) =>
(refKey(entry) === refKey(target) ? input.favorite : true) &&
(!known || known.has(refKey(entry)) || refKey(entry) === refKey(target)),
);
// Starring an existing favorite is idempotent and keeps its position.
if (input.favorite && !exists) next.push(target);
upsertPreferenceValue(this.db, MODEL_FAVORITES_PREFERENCE_KEY, next);
return next;
}
}

/** Marks catalog rows with their favorite position without reordering them. */
export function annotateModelFavorites<T>(
models: readonly T[],
favorites: readonly ModelFavoriteRef[],
): T[] {
const order = new Map(favorites.map((entry, index) => [refKey(entry), index]));
return models.map((model) => {
const ref = toRef(model);
const index = ref ? order.get(refKey(ref)) : undefined;
return index === undefined ? model : { ...model, favorite: true, favoriteOrder: index };
});
}

export function modelFavoriteRefs(models: readonly unknown[]): ModelFavoriteRef[] {
return models.flatMap((model) => {
const ref = toRef(model);
return ref ? [ref] : [];
});
}

function sanitize(value: unknown): ModelFavoriteRef[] {
if (!Array.isArray(value)) return [];
const seen = new Set<string>();
const result: ModelFavoriteRef[] = [];
for (const item of value) {
const ref = toRef(item);
if (!ref || seen.has(refKey(ref))) continue;
seen.add(refKey(ref));
result.push(ref);
}
return result;
}

function toRef(value: unknown): ModelFavoriteRef | undefined {
if (!value || typeof value !== 'object') return undefined;
const { providerId, modelId } = value as Record<string, unknown>;
if (typeof providerId !== 'string' || typeof modelId !== 'string') return undefined;
const provider = providerId.trim();
const model = modelId.trim();
return provider && model ? { providerId: provider, modelId: model } : undefined;
}

function refKey(ref: ModelFavoriteRef): string {
return `${ref.providerId}\u0000${ref.modelId}`;
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export * from './catalog/catalog.js';
export * from './catalog/config-fingerprint.js';
export * from './catalog/list-models.js';
export * from './catalog/model-cache.js';
export * from './catalog/model-favorites.js';
export * from './catalog/model-selection.js';
export * from './catalog/provider-views.js';
export * from './connectivity/discover-models.js';
Expand Down
2 changes: 2 additions & 0 deletions packages/local-runtime-v2/src/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ import {
} from "./service/miniapp/index.js";
import {
createLocalModelSystemConfigPort,
ModelFavoritesPreference,
resolveLocalRuntimeModelKey,
type ModelSystemOwner,
} from "./service/model-system/index.js";
Expand Down Expand Up @@ -530,6 +531,7 @@ export async function createRuntimeServices(
providers: modelSystem.providers,
listProviderPresets: modelSystem.listProviderPresets,
oauth: modelSystem.oauth,
favorites: new ModelFavoritesPreference(options.db),
},
});
if (owners.cron)
Expand Down
3 changes: 3 additions & 0 deletions packages/tui/src/runtime/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,9 @@ export class TuiRuntimeAdapter implements TuiRuntime {
): Promise<boolean> {
return this.productAccess.selectSessionModel(model, sessionId);
}
setModelFavorite(model: TuiModelSelection, favorite: boolean): Promise<boolean> {
return this.productAccess.setModelFavorite(model, favorite);
}
listUserModelProviders() {
return this.productAccess.listUserModelProviders();
}
Expand Down
Loading
Loading