1import {
2 ANTIGRAVITY_DEFAULT_MODEL,
3 DEFAULT_TEXT_GENERATION_MODEL,
4 DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER,
5 defaultInstanceIdForDriver,
6 type ModelSelection,
7 type ProjectId,
8 ProviderDriverKind,
9 ProviderInstanceId,
10 type ServerProvider,
11 type ServerSettings,
12} from "@t3tools/contracts";
13import {
14 type CustomModelDefinition,
15 createModelSelection,
16 normalizeCustomModelSlug,
17 readCustomModelEntries,
18 resolveSelectableModel,
19} from "@t3tools/shared/model";
20import { getComposerProviderState } from "./components/chat/composerProviderState";
21import { type ClientSettings, UnifiedSettings } from "@t3tools/contracts/settings";
22import { resolveModelPreferences } from "@t3tools/shared/serverSettings";
23import * as Arr from "effect/Array";
24import * as Equal from "effect/Equal";
25import * as Result from "effect/Result";
26import {
27 getDefaultServerModel,
28 getProviderModels,
29 resolveSelectableProvider,
30} from "./providerModels";
31import { ModelEsque } from "./components/chat/providerIconUtils";
32import {
33 type ProviderInstanceEntry,
34 deriveProviderInstanceEntries,
35 NO_PROVIDER_MODEL_SELECTION,
36 resolveDefaultProviderModelSelection,
37 resolveProjectDefaultProviderModelSelection,
38} from "./providerInstances";
39import { sortModelsForProviderInstance } from "./modelOrdering";
40import { resolveModelShortName } from "@t3tools/client-runtime/provider-clients";
41
42export const MAX_CUSTOM_MODEL_LENGTH = 256;
43const DEFAULT_TEXT_GENERATION_INSTANCE_ID = ProviderInstanceId.make("codex");
44
45// Legacy per-driver buckets would leak custom-instance models into stock instances.
46export function readInstanceCustomModels(
47 settings: UnifiedSettings,
48 instanceId: ProviderInstanceId,
49 driverKind: ProviderDriverKind,
50): ReadonlyArray<CustomModelDefinition> {
51 if (driverKind === "antigravity") return [];
52 const instance = settings.providerInstances?.[instanceId];
53 const config = instance?.config;
54 if (config !== null && typeof config === "object") {
55 const value = (config as Record<string, unknown>).customModels;
56 if (Array.isArray(value)) {
57 return readCustomModelEntries(value);
58 }
59 }
60 return [];
61}
62
63export interface AppModelOption {
64 slug: string;
65 name: string;
66 shortName?: string;
67 subProvider?: string;
68 aliases?: ReadonlyArray<string>;
69 badge?: "new";
70 isCustom: boolean;
71 isDefault?: boolean;
72 isLegacy?: boolean;
73 isUnavailable?: boolean;
74}
75
76function appendUnavailableDynamicModelSelection(
77 options: AppModelOption[],
78 rawModels: ReadonlyArray<ServerProvider["models"][number]>,
79 provider: ProviderDriverKind,
80 selectedModel: string | null | undefined,
81 hiddenModels: ReadonlyArray<string>,
82): AppModelOption[] {
83 if (provider !== "opencode" && provider !== "antigravity" && provider !== "cursor") {
84 return options;
85 }
86 const slug = normalizeCustomModelSlug(selectedModel);
87 if (!slug) return options;
88 if (provider === "antigravity" && slug === ANTIGRAVITY_DEFAULT_MODEL) return options;
89
90 if (resolveSelectableModel(provider, slug, rawModels) !== null) return options;
91 if (hiddenModels.includes(slug)) return options;
92 if (options.some((option) => option.slug === slug)) return options;
93
94 return [...options, { slug, name: slug, isCustom: false, isUnavailable: true }];
95}
96
97function toAppModelOption(
98 model: ServerProvider["models"][number],
99 driverKind: ProviderDriverKind,
100): AppModelOption {
101 const option: AppModelOption = {
102 slug: model.slug,
103 name: model.name,
104 isCustom: model.isCustom,
105 };
106 const shortName = resolveModelShortName(model, driverKind);
107 if (shortName) option.shortName = shortName;
108 if (model.subProvider) option.subProvider = model.subProvider;
109 if (model.aliases) option.aliases = model.aliases;
110 if (model.badge) option.badge = model.badge;
111 if (model.isDefault) option.isDefault = true;
112 if (model.isLegacy) option.isLegacy = true;
113 return option;
114}
115
116function readInstanceModelPreferences(
117 settings: UnifiedSettings,
118 instanceId: ProviderInstanceId,
119): { readonly hiddenModels: ReadonlyArray<string>; readonly modelOrder: ReadonlyArray<string> } {
120 return (
121 settings.providerModelPreferences?.[instanceId] ?? {
122 hiddenModels: [],
123 modelOrder: [],
124 }
125 );
126}
127
128function applyInstanceModelPreferences(
129 options: ReadonlyArray<AppModelOption>,
130 preferences: {
131 readonly hiddenModels: ReadonlyArray<string>;
132 readonly modelOrder: ReadonlyArray<string>;
133 },
134): AppModelOption[] {
135 const hiddenModels = new Set(preferences.hiddenModels);
136 return sortModelsForProviderInstance(
137 options.filter((option) => option.isCustom || !hiddenModels.has(option.slug)),
138 { modelOrder: preferences.modelOrder },
139 );
140}
141
142function normalizeCustomModelEntries(
143 models: ReadonlyArray<CustomModelDefinition>,
144 builtInModelSlugs: ReadonlySet<string>,
145): CustomModelDefinition[] {
146 const normalizedModels: CustomModelDefinition[] = [];
147 const seen = new Set<string>();
148
149 for (const candidate of models) {
150 if (
151 candidate.slug.length > MAX_CUSTOM_MODEL_LENGTH ||
152 builtInModelSlugs.has(candidate.slug) ||
153 seen.has(candidate.slug)
154 ) {
155 continue;
156 }
157
158 seen.add(candidate.slug);
159 normalizedModels.push(candidate);
160 }
161
162 return normalizedModels;
163}
164
165function getAppModelOptions(
166 settings: UnifiedSettings,
167 providers: ReadonlyArray<ServerProvider>,
168 provider: ProviderDriverKind,
169 selectedModel?: string | null,
170): AppModelOption[] {
171 const rawModels = getProviderModels(providers, provider);
172 // Server custom-model snapshots can lag removals; rebuild custom rows from settings.
173 const options: AppModelOption[] = rawModels
174 .filter((model) => !model.isCustom)
175 .map((model) => toAppModelOption(model, provider));
176 const seen = new Set(options.map((option) => option.slug));
177 const builtInModelSlugs = new Set(
178 Arr.filterMap(getProviderModels(providers, provider), (model) =>
179 model.isCustom ? Result.failVoid : Result.succeed(model.slug),
180 ),
181 );
182
183 const defaultInstanceId = defaultInstanceIdForDriver(provider);
184 const customModels = readInstanceCustomModels(settings, defaultInstanceId, provider);
185 for (const entry of normalizeCustomModelEntries(customModels, builtInModelSlugs)) {
186 if (seen.has(entry.slug)) {
187 continue;
188 }
189
190 seen.add(entry.slug);
191 const shortName = resolveModelShortName(entry, provider);
192 options.push({
193 slug: entry.slug,
194 name: entry.name,
195 isCustom: true,
196 ...(shortName ? { shortName } : {}),
197 });
198 }
199
200 const preferences = readInstanceModelPreferences(settings, defaultInstanceId);
201 return appendUnavailableDynamicModelSelection(
202 applyInstanceModelPreferences(options, preferences),
203 rawModels,
204 provider,
205 selectedModel,
206 preferences.hiddenModels,
207 );
208}
209
210export function getAppModelOptionsForInstance(
211 settings: UnifiedSettings,
212 entry: ProviderInstanceEntry,
213 selectedModel?: string | null,
214): AppModelOption[] {
215 const options: AppModelOption[] = entry.models
216 .filter((model) => !model.isCustom)
217 .map((model) => toAppModelOption(model, entry.driverKind));
218 const seen = new Set(options.map((option) => option.slug));
219 const builtInModelSlugs = new Set(
220 Arr.filterMap(entry.models, (model) =>
221 model.isCustom ? Result.failVoid : Result.succeed(model.slug),
222 ),
223 );
224
225 const customModels = readInstanceCustomModels(settings, entry.instanceId, entry.driverKind);
226 for (const custom of normalizeCustomModelEntries(customModels, builtInModelSlugs)) {
227 if (seen.has(custom.slug)) {
228 continue;
229 }
230
231 seen.add(custom.slug);
232 const shortName = resolveModelShortName(custom, entry.driverKind);
233 options.push({
234 slug: custom.slug,
235 name: custom.name,
236 isCustom: true,
237 ...(shortName ? { shortName } : {}),
238 });
239 }
240
241 const preferences = readInstanceModelPreferences(settings, entry.instanceId);
242 return appendUnavailableDynamicModelSelection(
243 applyInstanceModelPreferences(options, preferences),
244 entry.models,
245 entry.driverKind,
246 selectedModel,
247 preferences.hiddenModels,
248 );
249}
250
251export function resolveAppModelSelection(
252 provider: ProviderDriverKind,
253 settings: UnifiedSettings,
254 providers: ReadonlyArray<ServerProvider>,
255 selectedModel: string | null | undefined,
256): string {
257 const resolvedProvider = resolveSelectableProvider(providers, provider);
258 const options = getAppModelOptions(settings, providers, resolvedProvider, selectedModel);
259 return (
260 resolveSelectableModel(resolvedProvider, selectedModel, options) ??
261 getDefaultServerModel(providers, resolvedProvider)
262 );
263}
264
265export function resolveAppModelSelectionForInstance(
266 instanceId: ProviderInstanceId,
267 settings: UnifiedSettings,
268 providers: ReadonlyArray<ServerProvider>,
269 selectedModel: string | null | undefined,
270 resolutionOptions?: { readonly preserveUnavailableSelection?: boolean },
271): string | null {
272 const entry = deriveProviderInstanceEntries(providers).find(
273 (candidate) => candidate.instanceId === instanceId,
274 );
275 if (!entry) return null;
276 const options = getAppModelOptionsForInstance(
277 settings,
278 entry,
279 resolutionOptions?.preserveUnavailableSelection ? selectedModel : null,
280 );
281 const resolvedSelection = resolveSelectableModel(entry.driverKind, selectedModel, options);
282 if (resolvedSelection) {
283 return resolvedSelection;
284 }
285 if (
286 resolutionOptions?.preserveUnavailableSelection &&
287 (entry.driverKind === "opencode" ||
288 entry.driverKind === "antigravity" ||
289 entry.driverKind === "cursor")
290 ) {
291 const unavailableSelection = normalizeCustomModelSlug(selectedModel);
292 const hiddenModels = readInstanceModelPreferences(settings, entry.instanceId).hiddenModels;
293 if (
294 unavailableSelection &&
295 !hiddenModels.includes(unavailableSelection) &&
296 resolveSelectableModel(entry.driverKind, selectedModel, entry.models) === null &&
297 (entry.driverKind !== "antigravity" || unavailableSelection !== ANTIGRAVITY_DEFAULT_MODEL)
298 ) {
299 return unavailableSelection;
300 }
301 }
302 return options.find((option) => option.isDefault)?.slug ?? options[0]?.slug ?? null;
303}
304
305// New drafts carry the stored selection; capabilities resolve its raw options later.
306export function resolveEffectiveDefaultModelSelection(
307 settings: UnifiedSettings,
308 providers: ReadonlyArray<ServerProvider>,
309 selection: ModelSelection,
310): ModelSelection {
311 const model = resolveAppModelSelectionForInstance(
312 selection.instanceId,
313 settings,
314 providers,
315 selection.model,
316 { preserveUnavailableSelection: true },
317 );
318 return model === null || model === selection.model ? selection : { ...selection, model };
319}
320
321export function defaultModelSelectionsAreMixed(
322 clientSettings: ClientSettings,
323 targets: ReadonlyArray<{
324 projectId: ProjectId | null;
325 settings: ServerSettings;
326 providers: ReadonlyArray<ServerProvider>;
327 }>,
328): boolean {
329 const selections = targets.map(({ projectId, settings, providers }) => {
330 const stored = settings.defaultModelSelection;
331 const selection =
332 stored === null
333 ? resolveProjectDefaultProviderModelSelection(providers, settings, projectId)
334 : resolveDefaultProviderModelSelection(providers, stored);
335 return {
336 stored,
337 effective: selection
338 ? resolveEffectiveDefaultModelSelection(
339 {
340 ...clientSettings,
341 ...settings,
342 ...resolveModelPreferences(settings, clientSettings),
343 },
344 providers,
345 selection,
346 )
347 : null,
348 };
349 });
350 return selections.some((selection) => !Equal.equals(selection, selections[0]));
351}
352
353export function getCustomModelOptionsByInstance(
354 settings: UnifiedSettings,
355 providers: ReadonlyArray<ServerProvider>,
356 selectedInstanceId?: ProviderInstanceId | null,
357 selectedModel?: string | null,
358): ReadonlyMap<ProviderInstanceId, ReadonlyArray<ModelEsque>> {
359 const out = new Map<ProviderInstanceId, ReadonlyArray<ModelEsque>>();
360 for (const entry of deriveProviderInstanceEntries(providers)) {
361 out.set(
362 entry.instanceId,
363 getAppModelOptionsForInstance(
364 settings,
365 entry,
366 entry.instanceId === selectedInstanceId ? selectedModel : null,
367 ),
368 );
369 }
370 return out;
371}
372
373function getTextGenerationModelOptionsForInstance(
374 settings: UnifiedSettings,
375 entry: ProviderInstanceEntry,
376 selectedModel?: string | null,
377): AppModelOption[] {
378 const unsupported = new Set(
379 entry.models
380 .filter((model) => model.supportsTextGeneration === false)
381 .map((model) => model.slug),
382 );
383 // Filter after custom entries are rebuilt so a display-name override cannot restore a model.
384 return getAppModelOptionsForInstance(settings, entry, selectedModel).filter(
385 (option) => !unsupported.has(option.slug),
386 );
387}
388
389export function getTextGenerationModelOptionsByInstance(
390 settings: UnifiedSettings,
391 providers: ReadonlyArray<ServerProvider>,
392 selectedInstanceId?: ProviderInstanceId | null,
393 selectedModel?: string | null,
394): ReadonlyMap<ProviderInstanceId, ReadonlyArray<ModelEsque>> {
395 return new Map(
396 deriveProviderInstanceEntries(
397 providers.filter((provider) => provider.supportsTextGeneration !== false),
398 ).map((entry) => [
399 entry.instanceId,
400 getTextGenerationModelOptionsForInstance(
401 settings,
402 entry,
403 entry.instanceId === selectedInstanceId ? selectedModel : null,
404 ),
405 ]),
406 );
407}
408
409export function selectsPlanAgent(options: ModelSelection["options"]): boolean {
410 return options?.some((option) => option.id === "agent" && option.value === "plan") ?? false;
411}
412
413export function resolveAppModelSelectionState(
414 settings: UnifiedSettings,
415 providers: ReadonlyArray<ServerProvider>,
416): ModelSelection {
417 const selection = settings.textGenerationModelSelection ?? {
418 instanceId: DEFAULT_TEXT_GENERATION_INSTANCE_ID,
419 model: DEFAULT_TEXT_GENERATION_MODEL,
420 };
421 const supportedProviders = providers.filter(
422 (provider) => provider.supportsTextGeneration !== false,
423 );
424 const entries = deriveProviderInstanceEntries(supportedProviders);
425 const selectedEntry = entries.find(
426 (entry) => entry.instanceId === selection.instanceId && entry.enabled && entry.isAvailable,
427 );
428 const entry =
429 selectedEntry ?? entries.find((candidate) => candidate.enabled && candidate.isAvailable);
430 if (entry) {
431 const selectedModel = selectedEntry ? selection.model : null;
432 // Use the cheap generation default; the catalog default is the expensive chat flagship.
433 const options = getTextGenerationModelOptionsForInstance(settings, entry);
434 const model =
435 resolveSelectableModel(entry.driverKind, selectedModel, options) ??
436 resolveSelectableModel(
437 entry.driverKind,
438 DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[entry.driverKind],
439 options,
440 ) ??
441 options.find((option) => option.isDefault)?.slug ??
442 options[0]?.slug ??
443 entry.models.find((model) => model.supportsTextGeneration !== false)?.slug ??
444 DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[entry.driverKind];
445 if (!model) {
446 return createModelSelection(entry.instanceId, "", []);
447 }
448 const provider = entry.driverKind;
449 const { modelOptionsForDispatch } = getComposerProviderState({
450 provider,
451 model,
452 models: entry.models,
453 modelOptions: selectedEntry ? selection.options : undefined,
454 includePlanAgent: selectsPlanAgent(selection.options),
455 });
456
457 return createModelSelection(entry.instanceId, model, modelOptionsForDispatch);
458 }
459
460 return NO_PROVIDER_MODEL_SELECTION;
461}