1import type { MenuAction } from "@react-native-menu/menu";
2import type {
3 ModelCapabilities,
4 ModelSelection,
5 ProjectId,
6 RuntimeMode,
7 ServerConfig as T3ServerConfig,
8} from "@t3tools/contracts";
9import { resolveProjectProviderInstanceEnabled } from "@t3tools/contracts";
10import {
11 buildExplicitProviderOptionSelectionsFromDescriptors,
12 getProviderOptionDescriptors,
13} from "@t3tools/shared/model";
14
15export type ModelOption = {
16 readonly key: string;
17 readonly label: string;
18 readonly subtitle: string;
19 readonly providerKey: string;
20 readonly providerLabel: string;
21 readonly providerDriver: string;
22 readonly supportedRuntimeModes?: ReadonlyArray<RuntimeMode>;
23 readonly providerIconUrl?: string | undefined;
24 readonly isDefault: boolean;
25 readonly isLegacy: boolean;
26 readonly isUnavailable?: boolean;
27 readonly isHidden?: boolean;
28 readonly canHide?: boolean;
29 readonly capabilities: ModelCapabilities | null;
30 readonly selection: ModelSelection;
31 readonly providerUpdateRequired?: ProviderUpdateRequired;
32};
33
34type ProviderUpdateRequired = Pick<
35 T3ServerConfig["providers"][number],
36 "driver" | "updateRequiredModels"
37>;
38
39export type ProviderGroup = {
40 readonly providerKey: string;
41 readonly providerLabel: string;
42 readonly models: ReadonlyArray<ModelOption>;
43 /** The provider fields that name announced models its CLI is too old to run. */
44 readonly updateRequired?: ProviderUpdateRequired;
45};
46
47function providerDisplayLabel(provider: {
48 readonly displayName?: string | undefined;
49 readonly driver: string;
50 readonly instanceId: string;
51}): string {
52 if (provider.displayName) return provider.displayName;
53 if (provider.driver === "codex") return "Codex";
54 if (provider.driver === "claudeAgent") return "Claude";
55 if (provider.driver === "pi") return "Pi";
56 return provider.instanceId;
57}
58
59/**
60 * `provider.enabled` reflects the machine plus any project's keep-alive
61 * override, so it alone can't tell this project apart from one that merely
62 * shares the instance. Settings carry the real per-project view the server
63 * itself enforces, so a project with no override of its own must resolve
64 * from there, not from a snapshot another project may be keeping alive.
65 */
66function isProviderEnabledForProject(
67 config: T3ServerConfig | null | undefined,
68 provider:
69 | { readonly instanceId: ModelSelection["instanceId"]; readonly enabled: boolean }
70 | undefined,
71 projectId: ProjectId | null,
72): boolean {
73 if (!provider) {
74 return false;
75 }
76 return config?.settings
77 ? resolveProjectProviderInstanceEnabled(config.settings, projectId, provider.instanceId)
78 : provider.enabled;
79}
80
81/**
82 * Whether the project has explicitly turned this instance off, as opposed
83 * to it merely being disabled or unreachable at the machine level.
84 */
85function isProjectDisabledOverride(
86 config: T3ServerConfig | null | undefined,
87 instanceId: ModelSelection["instanceId"],
88 projectId: ProjectId | null,
89): boolean {
90 if (!projectId || !config?.settings) {
91 return false;
92 }
93 return (
94 config.settings.projectSettingsOverrides[projectId]?.providerInstances?.[instanceId]
95 ?.enabled === false
96 );
97}
98
99/**
100 * Whether the project cannot use this instance at all, whether the project
101 * turned it off itself or it merely inherits a machine-level disable. Either
102 * way the server's `ThreadManagementService.dispatch` refuses a send, and the
103 * instance may still look installed and running in the snapshot because
104 * another project's keep-alive left it up.
105 */
106function isDisabledForProject(
107 config: T3ServerConfig | null | undefined,
108 provider:
109 | { readonly instanceId: ModelSelection["instanceId"]; readonly enabled: boolean }
110 | undefined,
111 instanceId: ModelSelection["instanceId"],
112 projectId: ProjectId | null,
113): boolean {
114 if (isProjectDisabledOverride(config, instanceId, projectId)) {
115 return true;
116 }
117 return provider !== undefined && !isProviderEnabledForProject(config, provider, projectId);
118}
119
120function normalizeSelectionOptions(
121 selection: ModelSelection,
122 capabilities: ModelCapabilities | null,
123): ModelSelection {
124 if (!capabilities) {
125 return selection;
126 }
127 if (!selection.options?.length) {
128 return { instanceId: selection.instanceId, model: selection.model };
129 }
130 const options = buildExplicitProviderOptionSelectionsFromDescriptors(
131 getProviderOptionDescriptors({
132 caps: capabilities,
133 selections: selection.options,
134 }),
135 selection.options,
136 );
137 return options
138 ? { ...selection, options }
139 : {
140 instanceId: selection.instanceId,
141 model: selection.model,
142 };
143}
144
145/**
146 * Whether the selection cannot be sent as is: the instance resolves disabled
147 * for the project (its own override, or an inherited machine-level disable),
148 * or a known Antigravity selection needs setup or a different model.
149 *
150 * `projectId` has no default: a caller that forgets it would silently check
151 * only the machine-wide state and miss a project that turned this instance
152 * off. Pass `null` explicitly outside a project's scope.
153 */
154export function isModelSelectionUnavailable(
155 config: T3ServerConfig | null | undefined,
156 selection: ModelSelection | null | undefined,
157 projectId: ProjectId | null,
158): boolean {
159 if (!config || !selection) {
160 return false;
161 }
162 const provider = config.providers.find(
163 (candidate) => candidate.instanceId === selection.instanceId,
164 );
165 if (isDisabledForProject(config, provider, selection.instanceId, projectId)) {
166 return true;
167 }
168 const driver =
169 provider?.driver ?? config.settings?.providerInstances[selection.instanceId]?.driver;
170 return (
171 driver === "antigravity" &&
172 (!provider ||
173 !provider.installed ||
174 provider.auth.status === "unauthenticated" ||
175 provider.availability === "unavailable" ||
176 !provider.models.some((model) => model.slug === selection.model))
177 );
178}
179
180/** Why `isModelSelectionUnavailable` refused a selection, worded for the recovery that applies. */
181export function describeUnavailableModelSelection(
182 config: T3ServerConfig | null | undefined,
183 selection: ModelSelection,
184 projectId: ProjectId | null,
185): { readonly title: string; readonly detail: string } {
186 const provider = config?.providers.find(
187 (candidate) => candidate.instanceId === selection.instanceId,
188 );
189 return isDisabledForProject(config, provider, selection.instanceId, projectId)
190 ? {
191 title: "Model turned off for this project",
192 detail: "Turn it back on from web or desktop, or choose another model.",
193 }
194 : {
195 title: "Antigravity model unavailable",
196 detail: "Set up Antigravity on web or desktop, or choose another model.",
197 };
198}
199
200/**
201 * Keep Antigravity selections when setup or catalog changes make them
202 * unavailable. Other providers fall through to the server default when they
203 * are disabled, missing, or signed out. Without config, keep stored selections.
204 *
205 * `projectId` has no default; see `isModelSelectionUnavailable`.
206 */
207export function resolveSelectableModelSelection(
208 config: T3ServerConfig | null | undefined,
209 selection: ModelSelection | null,
210 projectId: ProjectId | null,
211): ModelSelection | null {
212 if (!selection || !config) {
213 return selection;
214 }
215 const provider = config.providers.find(
216 (candidate) => candidate.instanceId === selection.instanceId,
217 );
218 const driver =
219 provider?.driver ?? config.settings?.providerInstances[selection.instanceId]?.driver;
220 if (driver === "antigravity") {
221 return isProjectDisabledOverride(config, selection.instanceId, projectId) ? null : selection;
222 }
223 return provider &&
224 isProviderEnabledForProject(config, provider, projectId) &&
225 provider.installed &&
226 provider.auth.status !== "unauthenticated"
227 ? selection
228 : null;
229}
230
231/**
232 * Reject legacy models for implicit defaults, except Antigravity selections,
233 * which must not silently change after a catalog update. Explicit picks in
234 * the settings sheet are unaffected.
235 *
236 * `projectId` has no default; see `isModelSelectionUnavailable`.
237 */
238export function resolveDefaultableModelSelection(
239 config: T3ServerConfig | null | undefined,
240 selection: ModelSelection | null,
241 projectId: ProjectId | null,
242): ModelSelection | null {
243 const usable = resolveSelectableModelSelection(config, selection, projectId);
244 if (!usable || !config) {
245 return usable;
246 }
247 const provider = config.providers.find((candidate) => candidate.instanceId === usable.instanceId);
248 const model = provider?.models.find((candidate) => candidate.slug === usable.model);
249 return provider?.driver !== "antigravity" && model?.isLegacy === true ? null : usable;
250}
251
252export function resolveNewTaskModelSelection(input: {
253 readonly draftSelection: ModelSelection | null;
254 readonly projectDefaultSelection: ModelSelection | null;
255 readonly stickySelection: ModelSelection | null;
256 readonly modelOptions: ReadonlyArray<ModelOption>;
257}): ModelSelection | null {
258 return (
259 input.draftSelection ??
260 input.projectDefaultSelection ??
261 input.stickySelection ??
262 input.modelOptions.find((option) => option.isDefault && !option.isUnavailable)?.selection ??
263 input.modelOptions.find((option) => !option.isUnavailable)?.selection ??
264 null
265 );
266}
267
268/**
269 * `projectId` has no default; see `isModelSelectionUnavailable`.
270 */
271export function buildModelOptions(
272 config: T3ServerConfig | null | undefined,
273 fallbackModelSelection: ModelSelection | null,
274 providerInstanceId: ModelSelection["instanceId"] | undefined,
275 projectId: ProjectId | null,
276 includeHidden = false,
277): ReadonlyArray<ModelOption> {
278 const options = new Map<string, ModelOption>();
279
280 for (const provider of config?.providers ?? []) {
281 if (
282 (providerInstanceId !== undefined && provider.instanceId !== providerInstanceId) ||
283 !isProviderEnabledForProject(config, provider, projectId) ||
284 !provider.installed ||
285 provider.auth.status === "unauthenticated" ||
286 (provider.driver === "antigravity" && provider.availability === "unavailable")
287 ) {
288 continue;
289 }
290
291 const providerLabel = providerDisplayLabel(provider);
292 const updateRequired = provider.updateRequiredModels?.length
293 ? { driver: provider.driver, updateRequiredModels: provider.updateRequiredModels }
294 : undefined;
295 const preferences = config?.settings?.providerModelPreferences?.[provider.instanceId];
296 const hiddenModels = new Set(preferences?.hiddenModels ?? []);
297 const order = new Map((preferences?.modelOrder ?? []).map((slug, index) => [slug, index]));
298 const models = provider.models.filter(
299 (model) => includeHidden || model.isCustom || !hiddenModels.has(model.slug),
300 );
301 if (order.size > 0)
302 models.sort((a, b) => (order.get(a.slug) ?? Infinity) - (order.get(b.slug) ?? Infinity));
303 for (const model of models) {
304 const key = `${provider.instanceId}:${model.slug}`;
305 options.set(key, {
306 key,
307 label: model.name,
308 subtitle: model.subProvider ?? "",
309 providerKey: provider.instanceId,
310 providerLabel,
311 providerDriver: provider.driver,
312 ...(provider.supportedRuntimeModes === undefined
313 ? {}
314 : { supportedRuntimeModes: provider.supportedRuntimeModes }),
315 ...(provider.iconUrl ? { providerIconUrl: provider.iconUrl } : {}),
316 isHidden: !model.isCustom && hiddenModels.has(model.slug),
317 canHide: !model.isCustom,
318 isDefault: model.isDefault === true,
319 isLegacy: model.isLegacy === true,
320 ...(updateRequired ? { providerUpdateRequired: updateRequired } : {}),
321 capabilities: model.capabilities,
322 selection: normalizeSelectionOptions(
323 {
324 instanceId: provider.instanceId,
325 model: model.slug,
326 },
327 model.capabilities,
328 ),
329 });
330 }
331 }
332
333 if (
334 fallbackModelSelection &&
335 (providerInstanceId === undefined ||
336 fallbackModelSelection.instanceId === providerInstanceId) &&
337 (!config?.settings?.providerModelPreferences?.[
338 fallbackModelSelection.instanceId
339 ]?.hiddenModels.includes(fallbackModelSelection.model) ||
340 config?.providers
341 .find((provider) => provider.instanceId === fallbackModelSelection.instanceId)
342 ?.models.find((model) => model.slug === fallbackModelSelection.model)?.isCustom)
343 ) {
344 const key = `${fallbackModelSelection.instanceId}:${fallbackModelSelection.model}`;
345 const existing = options.get(key);
346 if (existing) {
347 options.set(key, {
348 ...existing,
349 selection:
350 existing.providerDriver === "antigravity"
351 ? fallbackModelSelection
352 : normalizeSelectionOptions(fallbackModelSelection, existing.capabilities),
353 });
354 } else {
355 const provider = config?.providers.find(
356 (candidate) => candidate.instanceId === fallbackModelSelection.instanceId,
357 );
358 const instanceConfig = config?.settings?.providerInstances[fallbackModelSelection.instanceId];
359 const model = provider?.models.find(
360 (candidate) => candidate.slug === fallbackModelSelection.model,
361 );
362 const providerDriver =
363 provider?.driver ?? instanceConfig?.driver ?? fallbackModelSelection.instanceId;
364 const providerLabel = providerDisplayLabel({
365 driver: providerDriver,
366 displayName: provider?.displayName ?? instanceConfig?.displayName,
367 instanceId: fallbackModelSelection.instanceId,
368 });
369 options.set(key, {
370 key,
371 label: model?.name ?? fallbackModelSelection.model,
372 subtitle: model?.subProvider ?? "",
373 providerKey: fallbackModelSelection.instanceId,
374 providerLabel,
375 providerDriver,
376 isDefault: false,
377 isLegacy: model?.isLegacy === true,
378 ...(isModelSelectionUnavailable(config, fallbackModelSelection, projectId) ||
379 (provider !== undefined && !isProviderEnabledForProject(config, provider, projectId)) ||
380 provider?.updateRequiredModels?.some((gated) => gated.slug === fallbackModelSelection.model)
381 ? { isUnavailable: true }
382 : {}),
383 capabilities: model?.capabilities ?? null,
384 selection: fallbackModelSelection,
385 });
386 }
387 }
388
389 return [...options.values()];
390}
391
392export function groupByProvider(options: ReadonlyArray<ModelOption>): ReadonlyArray<ProviderGroup> {
393 const groups = new Map<
394 string,
395 {
396 providerLabel: string;
397 models: ModelOption[];
398 updateRequired: ProviderUpdateRequired | undefined;
399 }
400 >();
401 for (const option of options) {
402 const existing = groups.get(option.providerKey);
403 if (existing) {
404 existing.models.push(option);
405 existing.updateRequired ??= option.providerUpdateRequired;
406 } else {
407 groups.set(option.providerKey, {
408 providerLabel: option.providerLabel,
409 models: [option],
410 updateRequired: option.providerUpdateRequired,
411 });
412 }
413 }
414
415 return [...groups.entries()].map(([providerKey, group]) => ({
416 providerKey,
417 providerLabel: group.providerLabel,
418 models: group.models,
419 ...(group.updateRequired ? { updateRequired: group.updateRequired } : {}),
420 }));
421}
422
423function modelMenuAction(option: ModelOption, selectedModel: ModelSelection | null): MenuAction {
424 return {
425 id: `model:${option.key}`,
426 title: option.label,
427 state:
428 option.selection.instanceId === selectedModel?.instanceId &&
429 option.selection.model === selectedModel.model
430 ? "on"
431 : undefined,
432 };
433}
434
435export function buildModelMenuActions(
436 groups: ReadonlyArray<ProviderGroup>,
437 selectedModel: ModelSelection | null,
438): MenuAction[] {
439 return groups.flatMap((group) => {
440 const currentModels = group.models.filter((model) => !model.isLegacy);
441 const legacyModels = group.models.filter((model) => model.isLegacy);
442 const selected = group.models.find(
443 (model) =>
444 model.selection.instanceId === selectedModel?.instanceId &&
445 model.selection.model === selectedModel.model,
446 );
447
448 return [
449 ...(currentModels.length > 0
450 ? [
451 {
452 id: `provider:${group.providerKey}`,
453 title: group.providerLabel,
454 subtitle: selected && !selected.isLegacy ? selected.label : undefined,
455 subactions: currentModels.map((option) => modelMenuAction(option, selectedModel)),
456 },
457 ]
458 : []),
459 ...(legacyModels.length > 0
460 ? [
461 {
462 id: `legacy-models:${group.providerKey}`,
463 title: `${group.providerLabel} legacy models`,
464 subtitle: selected?.isLegacy ? selected.label : undefined,
465 subactions: legacyModels.map((option) => modelMenuAction(option, selectedModel)),
466 },
467 ]
468 : []),
469 ];
470 });
471}